API Design Patterns — 8장 부분 업데이트와 부분 취득 딥다이브 학습 노트
출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/
8장에서 다루는 내용
- 필드 마스크를 사용해 API 요청의 범위를 줄이는 방법 — 거대한 리소스에서 필요한 필드만 골라서 가져오기
- 부분 취득(Partial Get)과 부분 업데이트(Partial Update) 지원 — GET/PATCH 요청에 fieldMask 쿼리 파라미터 활용
- 중첩 필드, 맵, 반복 필드를 필드 마스크로 다루는 방법 — 마침표(.), 와일드카드(*), 백틱(`) 표기법
- 묵시적 필드 마스크로 요청 본문에서 자동 추정하는 기법 — 명시적 fieldMask 없이도 업데이트할 필드를 판별
전체 흐름도
[문제 인식] [해결 도구] [구현 세부사항]
| | |
v v v
+------------------+ +------------------+ +---------------------------+
| 리소스가 너무 큼 | | | | 8.3.1 전달 방법 |
| (IoT, 모바일) |---->| 필드 마스크 |---->| (쿼리 파라미터) |
+------------------+ | (Field Mask) | +---------------------------+
| | |
+------------------+ | = 문자열 배열 | +---------------------------+
| replace 메서드로 |---->| ["title", |---->| 8.3.2 맵과 중첩 인터페이스|
| 데이터 유실 위험 | | "description"] | | (마침표.으로 경로 표현) |
+------------------+ | | +---------------------------+
+------------------+ |
| | +---------------------------+
v v | 8.3.3 반복적 필드 |
+----------+ +----------+ | (배열은 전체 교체만 가능) |
| 부분취득 | | 부분갱신 | +---------------------------+
| GET | | PATCH | |
|+fieldMask| |+fieldMask| +---------------------------+
+----------+ +----------+ | 8.3.4 기본값 |
| GET: "*" = 모든 필드 |
| PATCH: 묵시적 추정 |
+---------------------------+
|
+---------------------------+
| 8.3.5 묵시적 필드 마스크 |
| (보낸 데이터에서 자동 추정)|
+---------------------------+
선수 지식 체크리스트
- [ ] HTTP 메서드 기본 이해 (GET, PUT, PATCH, POST)
- [ ] REST API 기본 개념 (리소스, 엔드포인트)
- [ ] JSON 데이터 구조 (중첩 객체, 배열)
- [ ] 7장: 표준 메서드 (replace/update/get)
핵심 키워드
| 키워드 | 의미 |
|---|---|
| 필드 마스크 (Field Mask) | "이 필드만 보여줘" 또는 "이 필드만 바꿔줘" 라고 지정하는 문자열 목록 |
| 부분 취득 (Partial Get) | 리소스에서 원하는 필드만 가져오기 |
| 부분 업데이트 (Partial Update) | 리소스에서 원하는 필드만 수정하기 |
| 묵시적 필드 마스크 | 보낸 데이터를 보고 자동으로 어떤 필드를 수정할지 추정 |
8.1 서론 — 왜 부분 업데이트/취득이 필요한가?
한 줄 요약
리소스 전체를 주고받으면 데이터 낭비, 충돌, 유실이 생긴다. 필요한 부분만 다루자!
쉬운 설명 (피자 가게 비유)
지금까지의 방식 (replace 메서드)
피자 가게에서 피자를 주문했는데, 토핑을 하나만 바꾸고 싶다고 하자.
"페퍼로니 대신 버섯으로 바꿔주세요"
그런데 가게 규칙이 이렇다:
"토핑을 바꾸려면 피자 전체를 새로 주문해야 합니다. 도우, 소스, 치즈, 모든 토핑을 다시 말씀해주세요."
이게 바로 replace 메서드다. 필드 하나를 바꾸려면 리소스 전체를 다시 보내야 한다.
문제 1: 데이터 낭비
피자 전체 정보를 매번 보내야 함
→ IoT 기기처럼 작은 장비에서는 부담
→ 모바일 데이터가 비싼 환경에서도 문제
문제 2: 동시 수정 충돌 (데이터 유실)
사용자 1과 사용자 2가 동시에 같은 채팅방을 수정하는 상황:
사용자 1: "제목을 '내 채팅방'으로 바꿀래"
사용자 2: "설명을 '멋진 채팅방'으로 바꿀래"
둘 다 replace를 쓰면?
→ 나중에 보낸 사람이 먼저 보낸 사람의 변경을 덮어씀!
→ 사용자 1의 제목 변경이 사라질 수 있음
문제 3: 새 필드 추가 시 유실
API가 업데이트되어 description 필드가 새로 추가됐는데,
오래된 클라이언트는 이 필드를 모른다면?
1. 최신 클라이언트: description = "멋진 채팅방" (정상 설정)
2. 구 클라이언트: 제목만 바꾸려고 replace 호출
→ description 필드를 모르니까 빠뜨림
→ 서버는 "description을 삭제하라는 뜻이구나!" 라고 해석
→ description이 null로 변경됨! (데이터 유실)
실무 예제
// [나쁜 예] replace로 제목만 바꾸려는데 전체를 보내야 함
let chatRoom = GetChatRoom({ id: '1' });
// chatRoom = { id: '1', title: 'Old', description: 'Cool chat', settings: {...} }
chatRoom.title = "New Title";
// 전체 리소스를 다시 보냄 → 다른 사람이 바꾼 description이 덮어써질 위험
ReplaceChatRoom({ chatRoom: chatRoom });
// [좋은 예] 부분 업데이트로 제목만 정확히 수정
UpdateChatRoom({
resource: { title: "New Title" },
fieldMask: ["title"] // "제목만 바꿀 거야!" 라고 명시
});
// → description, settings 등 다른 필드는 절대 건드리지 않음
핵심 체크포인트
- 전체 교체(replace)는 데이터 낭비 + 충돌 위험 + 유실 위험이 있다
- 부분 취득은 필요한 필드만 가져와서 네트워크 비용을 줄인다
- 부분 업데이트는 수정할 필드만 명시하여 충돌과 유실을 방지한다
8.2 개괄 — 필드 마스크가 뭔가?
한 줄 요약
필드 마스크 = "이 필드들만 다뤄줘" 라고 서버에게 전달하는 문자열 목록(배열).
쉬운 설명 (마트 장바구니 비유)
마트에서 장보기를 한다고 생각해보자.
필드 마스크 없이 (전체 취득)
"마트에 있는 모든 물건을 다 보여주세요" → 물건이 10만 개면 트럭이 필요하다
필드 마스크 있이 (부분 취득)
"우유랑 빵만 보여주세요" → fieldMask: ["우유", "빵"] → 딱 2개만 가져오면 됨. 가방이면 충분!
부분 취득 흐름도
[소비자] [서버]
| |
| GET /chatRoom/1 |
| (필드 마스크 없음 = 전체 다 줘) |
|----------------------------------------->|
| |
| { id: 1, title: "Chat", |
| description: "Cool chat", ... } |
|<-----------------------------------------|
| |
| GET /chatRoom/1 |
| fieldMask: ["title"] |
| (제목만 줘!) |
|----------------------------------------->|
| |
| { id: 1, title: "Chat" } |
| (제목만 반환! 가볍다!) |
|<-----------------------------------------|
부분 업데이트 흐름도
[소비자] [서버]
| |
| PUT /chatRoom/1 |
| { 전체 리소스 } (전체 교체) |
|----------------------------------------->|
| { 전체 리소스 그대로 반환 } |
|<-----------------------------------------|
| |
| PATCH /chatRoom/1?fieldMask=title |
| { title: "New!" } |
| (제목만 바꿔줘!) |
|----------------------------------------->|
| |
| { id: 1, title: "New!", |
| description: "Cool chat", ... } |
| (제목만 바뀌고 나머지는 그대로!) |
|<-----------------------------------------|
실무 예제
// 필드 마스크 타입 정의
type FieldMask = string[];
// GET 요청에 필드 마스크 추가
interface GetChatRoomRequest {
id: string;
fieldMask: FieldMask; // ["title", "description"] 처럼 원하는 필드만 지정
}
// UPDATE 요청에 필드 마스크 추가
interface UpdateChatRoomRequest {
resource: ChatRoom; // 수정할 데이터
fieldMask: FieldMask; // ["title"] 처럼 수정할 필드만 지정
}
핵심 체크포인트
- 필드 마스크는 문자열 배열로, 다룰 필드 이름을 나열한 것이다
- 부분 취득: GET 요청에 fieldMask를 붙여 원하는 필드만 받는다
- 부분 업데이트: PATCH 요청에 fieldMask를 붙여 특정 필드만 수정한다
8.3 구현 — 어떻게 만들까?
8.3.1 전달 방법 — 필드 마스크를 어떻게 보내나?
한 줄 요약
쿼리 매개변수(?fieldMask=title&fieldMask=description)로 보내는 게 가장 좋다.
쉬운 설명 (편지 비유)
필드 마스크를 서버에 전달하는 방법은 2가지:
- 쿼리 매개변수 (편지 봉투 겉면에 쓰기) ← 추천!
- URL에 바로 보임:
?fieldMask=title&fieldMask=description -
브라우저에서도 쉽게 확인 가능
-
HTTP 헤더 (편지 봉투 안쪽에 숨기기)
- 눈에 안 보여서 디버깅하기 어려움
왜 요청 본문(body)에 넣으면 안 되나?
GET 요청 → 본문을 가질 수 없음 (HTTP 규칙)
PATCH 요청 → 본문은 리소스 데이터 전용 (API 디자인 규칙)
그래서 쿼리 매개변수가 정답이다!
프레임워크별 다중 값 쿼리 매개변수 처리
| 프레임워크 | 형식 | 비고 |
|---|---|---|
| PHP | ?fieldMask[]=a&fieldMask[]=b |
|
| Flask (Python) | ?fieldMask=a&fieldMask=b |
request.args.to_dict(flat=False) |
| Spring (Java) | ?fieldMask=a&fieldMask=b |
|
| Express (Node.js) | ?fieldMask=a&fieldMask=b |
실무 예제
# Flask에서 필드 마스크 처리
from flask import request
@app.route('/chatRooms/<id>', methods=['GET'])
def get_chat_room(id):
# 쿼리 파라미터에서 필드 마스크 가져오기
# URL: /chatRooms/1?fieldMask=title&fieldMask=description
field_mask = request.args.getlist('fieldMask')
# field_mask = ['title', 'description']
chat_room = db.get_chat_room(id)
if field_mask:
# 요청한 필드만 필터링해서 반환
result = {k: v for k, v in chat_room.items() if k in field_mask}
result['id'] = chat_room['id'] # id는 항상 포함
else:
result = chat_room # 필드 마스크 없으면 전체 반환
return jsonify(result)
# Flask에서 부분 업데이트 처리
@app.route('/chatRooms/<id>', methods=['PATCH'])
def update_chat_room(id):
field_mask = request.args.getlist('fieldMask')
data = request.get_json()
chat_room = db.get_chat_room(id)
if field_mask:
# 필드 마스크에 명시된 필드만 업데이트
for field in field_mask:
if field in data:
chat_room[field] = data[field]
db.save_chat_room(chat_room)
return jsonify(chat_room)
핵심 체크포인트
- 쿼리 매개변수가 필드 마스크 전달의 가장 좋은 방법이다
- GET은 본문을 가질 수 없고, PATCH 본문은 리소스 전용이다
- 프레임워크마다 다중 값 파라미터 처리가 다르니 표준 라이브러리를 쓰자
8.3.2 맵과 중첩 인터페이스 — 복잡한 구조는 어떻게?
한 줄 요약
마침표(.)로 중첩 경로를 표현하고, 별표(*)로 "전부"를 의미하며, 맵 키는 백틱(`)으로 감싼다.
쉬운 설명 (아파트 주소 비유)
필드 마스크는 "주소"와 같다.
단순 필드: "title" → 1층 거실
중첩 필드: "loggingConfig.maxSizeMb" → 3층 > 302호 > 거실
맵의 키: "settings.`1234`" → 설정동 > 1234호
전체 하위: "loggingConfig.*" → 3층 전체
5가지 규칙
1. 마침표(.) → 중첩 구조의 경로 구분자
예: "loggingConfig.maxSizeMb"
2. 별표(*) → "하위 전부" (foreach처럼)
예: "loggingConfig.*" = loggingConfig의 모든 필드
3. 맵 키 → 문자열 형식
예: settings 맵의 "1234" 키 → "settings.`1234`"
4. 따옴표 → 필드 이름이나 맵 키에 특수문자가 있으면 백틱(`)으로 감싸기
예: "settings.`hello`"
5. 백틱 자체 → 두 번 연속(``)으로 이스케이프
예: 키가 "he`lo"이면 → "settings.`he``lo`"
실무 예제 — ChatRoom 리소스
interface ChatRoom {
id: string;
title: string;
description: string;
loggingConfig: LoggingConfig; // 중첩 인터페이스
settings: Object; // 동적 맵 (키-값)
}
interface LoggingConfig {
maxSizeMb: number;
maxMessageCount: number;
}
실제 데이터와 필드 마스크 매핑:
ChatRoom 데이터 필드 마스크
───────────────────────────── ──────────────────────
{
"id": 1, "id"
"title": "Chat Room", "title"
"description": "Cool chat!", "description"
"loggingConfig": { "loggingConfig.*" (전체)
"maxSizeMb": 10, "loggingConfig.maxSizeMb"
"maxMessageCount": 1000 "loggingConfig.maxMessageCount"
},
"settings": { "settings.*" (전체)
"1234": "abcd", "settings.`1234`"
"`hello`": null, "settings.```hello```"
"*": "star", "settings.`*`"
"test.value": true "settings.`test.value`"
}
}
핵심 체크포인트
- 중첩 필드는 마침표(.)로 경로를 표현한다 (예: "a.b.c")
- 별표(*)는 "모든 하위 필드"를 의미한다
- 맵 키에 특수문자가 있으면 백틱으로 감싸야 한다
8.3.3 반복적 필드 — 배열은 어떻게 다루나?
한 줄 요약
배열(반복 필드)의 개별 항목은 인덱스로 접근하면 안 된다. 배열은 통째로 교체하는 게 원칙!
쉬운 설명 (줄 서기 비유)
놀이공원 줄을 생각해보자.
줄: [철수, 영희, 민수]
0번 1번 2번
"1번(영희)의 이름을 바꿔줘" 라고 하면? - 그 사이에 누군가 줄에 끼어들면 1번이 더 이상 영희가 아닐 수 있다! - 인덱스는 불안정한 식별자다
원래: [철수, 영희, 민수] → 1번 = 영희
누가 끼어듦: [철수, 지수, 영희, 민수] → 1번 = 지수 (영희가 아님!)
그래서 API에서는: - 배열 항목을 인덱스로 개별 수정하는 것을 금지 - 배열은 전체를 통째로 교체하거나 - 별표()를 써서 각 항목의 특정 필드를 읽기만* 가능
실무 예제
interface ChatRoom {
id: string;
title: string;
administrators: User[]; // 관리자 배열 (반복 필드)
}
interface User {
name: string;
email: string;
}
[읽기는 가능] 모든 관리자의 이름만 가져오기
GET /chatRooms/1?fieldMask=administrators.*.name
→ 각 관리자에 대해 name 필드만 반환
→ "administrators.*" = "각 관리자에 대해"
→ "administrators.*.name" = "각 관리자의 이름"
[쓰기는 전체 교체만] 관리자 목록 업데이트
PATCH /chatRooms/1?fieldMask=administrators
{
"administrators": [
{ "name": "Alice", "email": "[email protected]" },
{ "name": "Bob", "email": "[email protected]" }
]
}
→ administrators 배열 전체를 새 값으로 교체
→ 개별 항목만 수정하는 것은 불가능!
[나쁜 예] 인덱스로 개별 수정 시도 (하면 안 됨!)
PATCH /chatRooms/1?fieldMask=administrators.*.name
{
"administrators": [
{ "name": "New name for Index 0" },
{ "name": "New name for Index 1" }
]
}
→ 순서가 바뀌었을 수 있어서 엉뚱한 사람 이름이 바뀔 위험!
→ 이런 방식은 사용하지 말 것
대안: 개별 항목 수정이 정말 필요하다면? 1. 맵 필드를 사용 (키가 안정적인 식별자 역할) 2. 서브리소스로 분리 (각 항목에 고유 ID 부여)
핵심 체크포인트
- 배열의 인덱스는 불안정해서 개별 항목 수정에 사용하면 안 된다
- 배열은 전체를 통째로 교체하는 것이 원칙이다
- 개별 항목 조회는 별표(*)로 가능하지만, 개별 수정은 불가능하다
8.3.4 기본값 — 필드 마스크를 안 보내면?
한 줄 요약
GET은 기본값이 "모든 필드"(*), PATCH는 "보낸 데이터에서 자동 추정".
쉬운 설명 (음식 주문 비유)
GET (부분 취득)의 기본값:
식당에서 "메뉴 보여주세요" 라고만 하면? → 전체 메뉴판을 보여줌 (기본값 = 전부)
"디저트 메뉴만 보여주세요" → fieldMask: ["디저트"] → 디저트만 보여줌
PATCH (부분 업데이트)의 기본값:
"이 피자 토핑을 버섯으로 바꿔주세요" → 보낸 내용을 보고 "아, 토핑만 바꾸면 되는구나" 자동 추정 → 묵시적 필드 마스크!
예외: 기본 필드 세트에서 제외되는 필드
보통은 필드 마스크를 안 보내면 모든 필드를 반환하지만, 계산 비용이 크거나 용량이 큰 필드는 기본에서 제외할 수 있다.
예: ChatRoom에 "analytics" 필드가 있는데
이 값을 계산하는 데 3분이 걸린다면?
→ 기본 필드 세트에서 제외
→ 사용자가 명시적으로 fieldMask=analytics를 보내야만 반환
→ 이런 필드는 반드시 API 문서에 명시해야 함!
센티넬 값 — "모든 것"을 의미하는 특별한 값
fieldMask: ["*"] → "모든 필드를 반환해줘"
용도: 클라이언트가 마지막으로 업데이트한 이후 새 필드가 추가됐을 때
새 필드도 포함해서 가져오고 싶은 경우
핵심 체크포인트
- GET의 기본값은 "전체 필드 반환" (필드 마스크 없음 = 전부)
- 특별히 비용이 큰 필드는 기본에서 제외하고 명시적 요청만 허용한다
- "*"는 "모든 필드"를 의미하는 센티넬 값이다
8.3.5 묵시적 필드 마스크 — 보낸 데이터에서 자동 추정
한 줄 요약
PATCH 요청 시 필드 마스크를 명시하지 않으면, 보낸 JSON 데이터에서 어떤 필드를 수정할지 자동으로 추정한다.
쉬운 설명 (택배 비유)
택배를 보내는데 "내용물 목록"을 안 적었다면?
→ 택배 기사가 상자를 열어보고 내용물을 확인
→ "아, 책이랑 펜이 들어있네. 이것만 전달하면 되겠다!"
PATCH에서 fieldMask를 안 보내면?
→ 서버가 요청 본문(JSON)을 열어보고 확인
→ "아, title이랑 description이 있네. 이것만 수정하면 되겠다!"
추정 알고리즘 (inferFieldMask)
type FieldMask = string[];
function inferFieldMask(resource: Object): FieldMask {
const fieldMask: FieldMask = [];
for (const [key, value] of Object.entries(resource)) {
if (value instanceof Object) {
// 중첩 객체면 재귀적으로 탐색
for (const field of inferFieldMask(value)) {
fieldMask.push(`${key}.${field}`);
}
} else if (value !== undefined) {
// undefined가 아닌 값이 있으면 → 이 필드를 수정하겠다는 의미
fieldMask.push(key);
}
// undefined인 필드는 무시 (수정 대상 아님)
}
return fieldMask;
}
실무 예제 — 묵시적 vs 명시적 필드 마스크
// [명시적] 필드 마스크를 직접 지정
fetch('/chatRooms/1?fieldMask=title', {
method: 'PATCH',
body: JSON.stringify({ title: "New Title" })
});
// → 서버: "title만 바꾸면 되는구나" (명시적)
// [묵시적] 필드 마스크 없이 보내기
fetch('/chatRooms/1', {
method: 'PATCH',
body: JSON.stringify({ title: "New Title" })
});
// → 서버: JSON을 분석해서 "title만 있네, title만 바꾸면 되겠다" (추정)
묵시적 필드 마스크의 동작 흐름
클라이언트가 보낸 JSON:
{ "title": "New!", "description": "Cool" }
↓ inferFieldMask() 실행
추정된 필드 마스크:
["title", "description"]
↓ 이 필드들만 업데이트
결과:
- title → "New!" (변경됨)
- description → "Cool" (변경됨)
- settings → 그대로 유지 (필드 마스크에 없으므로)
- loggingConfig → 그대로 유지
핵심 체크포인트
- 묵시적 필드 마스크는 보낸 데이터의 키를 분석해서 자동 추정한다
- undefined인 필드는 무시하고, 값이 있는 필드만 업데이트 대상이 된다
- 명시적 필드 마스크가 더 안전하지만, 묵시적 방식도 대부분 잘 동작한다
8.3.6 동적 데이터 구조 업데이트 — 맵에서 키를 삭제하려면?
한 줄 요약
동적 맵에서 키를 삭제하려면 필드 마스크에 해당 키를 명시하고 요청 본문은 빈 객체({})로 보낸다.
쉬운 설명 (사물함 비유)
사물함(맵)에 물건이 들어있다:
1번 칸: 책 → settings.`book` = "있음"
2번 칸: 펜 → settings.`pen` = "있음"
3번 칸: (비어있음) → settings.`box`는 존재하지 않음
"2번 칸의 물건을 바꾸고 싶다" → 값을 새로 넣으면 됨 (null도 가능)
"2번 칸 자체를 없애고 싶다" → undefined로 만들어야 하는데...
JSON에는 undefined가 없다!
핵심 문제: null vs undefined vs 존재하지 않음
정적 데이터 구조 (일반 필드):
값이 있음 → { "title": "Cool chat" }
값이 null → { "title": null }
→ 2가지 상태만 구분하면 됨
동적 데이터 구조 (맵 필드):
값이 있음 → { "test": "value" } ← 키 존재 + 값 있음
값이 null → { "test": null } ← 키 존재 + 값 null
키 자체 없음 → {} ← 키 존재하지 않음 (undefined)
→ 3가지 상태를 구분해야 함!
해결법: 필드 마스크 + 빈 본문
맵에서 "test" 키를 완전히 삭제하고 싶다면:
PATCH /chatRooms/1?fieldMask=settings.test HTTP/1.1
Content-Type: application/json
{} ← 본문에 settings.test 값이 없음
= undefined와 같은 효과
= 서버가 해당 키를 삭제함
비교:
값을 null로 설정: { "settings": { "test": null } } → 키는 남고 값만 null
키를 완전히 삭제: {} + fieldMask=settings.test → 키 자체가 사라짐
실무 예제
# Python에서의 주의점
# None으로 설정하는 것 ≠ 키를 삭제하는 것
settings = {"test": "value", "mode": "dark"}
# 방법 1: 값을 None으로 (키는 남음)
settings["test"] = None
# → {"test": None, "mode": "dark"}
# 방법 2: 키를 삭제 (키 자체가 사라짐)
del settings["test"]
# → {"mode": "dark"}
# API에서 방법 2를 달성하려면:
# fieldMask=settings.test + 빈 본문을 보내야 함
핵심 체크포인트
- 동적 맵은 "값 있음/null/키 없음" 3가지 상태를 구분해야 한다
- JSON에는 undefined가 없으므로 맵 키 삭제가 까다롭다
- 필드 마스크에 키를 명시 + 빈 본문으로 보내면 키를 삭제할 수 있다
8.3.7 유효하지 않은 필드 — 없는 필드를 요청하면?
한 줄 요약
존재하지 않는 필드를 필드 마스크에 넣어도 에러를 내지 말고, undefined로 취급해서 무시하는 게 안전하다.
쉬운 설명 (음식 메뉴 비유)
손님: "스테이크, 파스타, 그리고 '우주먹이'를 주세요"
셰프: "'우주먹이'는 메뉴에 없는데... 에러를 낼까?"
나쁜 대응: "우주먹이가 뭡니까?! 주문 거부합니다!" (에러)
좋은 대응: "우주먹이는 없으니 스테이크와 파스타만 드리겠습니다." (무시)
왜 에러를 내면 안 되는가?
시간이 지나면서 필드가 추가되고 삭제되는 것은 자연스러운 일.
→ 어떤 시점에 존재했지만 지금은 없는 필드를 요청하는 경우가 발생
→ 이때 에러를 내면 하위 호환성이 깨짐
방어적 코딩 원칙:
✅ 모든 구조를 동적으로 취급
✅ 필드 마스크의 필드가 리소스에 없어도 에러 없이 처리
✅ 없는 필드는 undefined 값으로 취급 → 데이터 업데이트/반환에서 무시됨
핵심 체크포인트
- 유효하지 않은 필드에 대해 에러를 내지 않는 것이 가장 안전하다
- 없는 필드는 undefined로 취급하여 무시한다
- API 진화 과정에서 필드 추가/삭제가 자연스러우므로, 에러 대신 관용적으로 처리해야 한다
8.3.8 최종 API 정의 — 완성된 형태
한 줄 요약
FieldMask 타입을 만들고, get/update 표준 메서드의 요청 인터페이스에 fieldMask 필드를 추가한다.
최종 코드
// 최종 API 정의
abstract class ChatRoomApi {
@get("/{id=chatRooms/*}")
GetChatRoom(req: GetChatRoomRequest): ChatRoom;
@patch("/{resource.id=chatRooms/*}")
UpdateChatRoom(req: UpdateChatRoomRequest): ChatRoom;
}
type FieldMask = string[];
interface GetChatRoomRequest {
id: string;
fieldMask: FieldMask; // 부분 취득용
}
interface UpdateChatRoomRequest {
resource: ChatRoom;
fieldMask: FieldMask; // 부분 업데이트용
}
핵심 체크포인트
- GET용 요청에 fieldMask를 추가하면 부분 취득이 가능하다
- PATCH용 요청에 fieldMask를 추가하면 부분 업데이트가 가능하다
- FieldMask는 단순한 문자열 배열(string[])이다
8.4 트레이드오프 — 언제 쓰고 언제 안 쓰나?
부분 취득 vs 전체 취득
| 항목 | 전체 취득 (기본) | 부분 취득 (필드 마스크) |
|---|---|---|
| 구현 복잡도 | 단순 | 필터링 로직 필요 |
| 네트워크 비용 | 높음 (큰 리소스일 때) | 낮음 |
| 적합한 상황 | 필드가 적은 리소스 | IoT, 모바일, 대용량 리소스 |
| API 호환성 | 항상 가능 | fieldMask 파라미터 추가 필요 |
부분 업데이트 vs 전체 교체
| 항목 | 전체 교체 (replace/PUT) | 부분 업데이트 (update/PATCH) |
|---|---|---|
| 데이터 안전성 | 동시 수정 시 유실 위험 | 안전 (지정 필드만 변경) |
| 구현 복잡도 | 단순 | 필드 마스크 처리 필요 |
| 하위 호환성 | 새 필드 추가 시 위험 | 안전 (모르는 필드는 무시) |
| 적합한 상황 | 리소스 전체를 알고 있을 때 | 특정 필드만 변경할 때 |
명시적 vs 묵시적 필드 마스크
| 항목 | 명시적 필드 마스크 | 묵시적 필드 마스크 |
|---|---|---|
| 정확도 | 100% 정확 | 대부분 정확 |
| 사용 편의성 | 매번 지정해야 함 | 자동 추정 |
| null 처리 | 명확 (fieldMask에 있으면 null로 설정) | 모호 (null이 "삭제"인지 "무시"인지) |
| 추천 상황 | 중요한 데이터, 정확도가 필수인 경우 | 간단한 업데이트, 프로토타이핑 |
8.4.1 보편 지원 — 모든 API에 넣어야 하나?
부분 업데이트는 거의 항상 가치가 있다 (동시성 문제 해결) 부분 취득은 선택적이다 (리소스가 크지 않으면 이득이 작음)
하지만 주의: 부분 취득을 지원하기로 했다면 모든 리소스에 일관되게 적용해야 한다. 일부 리소스만 지원하면 get 표준 메서드의 일관성이 깨져서 오히려 나쁜 API가 된다.
8.4.2 기타 구현 — 필드 마스크 외의 대안
| 대안 | 설명 | 장단점 |
|---|---|---|
| JSON Patch (RFC 6902) | 연산 목록으로 수정 (add, remove, replace, move, copy, test) | 매우 정교하지만 복잡함 |
| JSON Merge Patch (RFC 7396) | 묵시적 필드 마스크와 유사, JSON 기반 | 동적 구조에서 null/undefined 구분 어려움 |
| GraphQL | 쿼리 언어로 원하는 필드를 정확히 지정 | 관계형 데이터에 강력, 하지만 별도 생태계 |
필드 마스크는 대부분의 경우 빠르고 간단하면서도 충분히 강력하다.
8.5 연습문제 풀이
문제 1
ChatRoom 리소스가 여러 명의 관리자 컬렉션을 포함하고 있다고 해보자. 배열 필드를 사용해 이 리소스를 저장할 때의 단점은 무엇인가? 어떤 방식으로 이를 대체할 수 있을까?
풀이
배열 필드의 단점:
administrators: User[] ← 배열로 저장
문제 1: 개별 관리자 수정이 불가능
→ "3번째 관리자의 이메일만 바꾸고 싶다"
→ 인덱스가 불안정해서 어떤 관리자인지 특정 불가
→ 전체 배열을 교체해야 함
문제 2: 순서 보장 어려움
→ 다른 사용자가 관리자를 추가/삭제하면 인덱스가 밀림
→ 내가 알던 "0번 관리자"가 다른 사람일 수 있음
문제 3: 부분 취득의 한계
→ "첫 번째 관리자의 이름"만 가져올 수 있지만
→ "Alice라는 관리자의 이름"은 가져올 수 없음
대안 1: 맵 필드로 저장
// 키 = 관리자 고유 ID, 값 = User 객체
interface ChatRoom {
id: string;
title: string;
administrators: { [userId: string]: User };
}
// 사용 예:
// administrators: { "user_123": { name: "Alice", ... }, "user_456": { name: "Bob", ... } }
// 개별 수정 가능!
// PATCH /chatRooms/1?fieldMask=administrators.`user_123`.email
// { "administrators": { "user_123": { "email": "[email protected]" } } }
대안 2: 서브리소스로 분리
// 관리자를 별도의 API 리소스로 분리
GET /chatRooms/1/administrators → 관리자 목록
GET /chatRooms/1/administrators/user_123 → 개별 관리자
PATCH /chatRooms/1/administrators/user_123 → 개별 관리자 수정
DELETE /chatRooms/1/administrators/user_123 → 개별 관리자 삭제
문제 2
동적 데이터 구조에서 단일 속성을 제거하려면 어떻게 해야 할까?
풀이
핵심: JSON에는 undefined가 없어서 "키 삭제"를 직접 표현할 수 없다.
방법: 필드 마스크에 삭제할 키를 명시 + 요청 본문에서 해당 키를 생략
예시: settings 맵에서 "test" 키를 삭제하고 싶다면
PATCH /chatRooms/1?fieldMask=settings.`test` HTTP/1.1
Content-Type: application/json
{}
→ fieldMask에 settings.test가 있는데 본문에는 없음
→ 서버: "settings.test를 업데이트해야 하는데, 값이 없네?"
→ undefined로 해석 → 해당 키를 삭제
주의: null로 설정하는 것과 삭제는 다르다!
null 설정: { "settings": { "test": null } }
→ 키는 남아있고 값만 null → {"test": null, "mode": "dark"}
키 삭제: {} + fieldMask=settings.`test`
→ 키 자체가 사라짐 → {"mode": "dark"}
문제 3
특정 필드를 부분적으로 취득하겠다는 사실을 어떻게 전달해야 할까?
풀이
쿼리 매개변수를 사용한다!
GET /chatRooms/1?fieldMask=title&fieldMask=description
왜 쿼리 매개변수인가?
1. GET 요청은 본문(body)을 가질 수 없음 (HTTP 규칙)
2. 헤더는 숨겨져 있어서 디버깅이 어려움
3. URL에 바로 보여서 브라우저에서도 쉽게 확인 가능
프레임워크별 구현:
Flask: request.args.getlist('fieldMask')
Spring: @RequestParam List<String> fieldMask
Express: req.query.fieldMask (자동으로 배열)
문제 4
전체 필드를 취득(또는 업데이트)하고자 할 때 필드 마스크에 모든 필드의 목록을 나열하지 않으려면 어떤 방법을 사용해야 할까?
풀이
센티넬 값 "*" (별표)를 사용한다!
GET /chatRooms/1?fieldMask=*
→ "모든 필드를 반환해줘"
fieldMask=*는 리소스에 존재하는 모든 필드를 의미하는 특별한 값이다.
이게 유용한 이유:
- 클라이언트가 마지막으로 업데이트한 이후 서버에 새 필드가 추가되었을 수 있음
- 필드 목록을 하드코딩하면 새 필드를 놓침
- *를 쓰면 "지금 있는 모든 것"을 자동으로 포함
GET의 기본 동작(필드 마스크 없음)도 전체 필드 반환이지만,
*와의 차이: 기본에서 제외된 고비용 필드도 *에는 포함될 수 있음
문제 5
setting 맵에서 "hello.world" 키를 업데이트하려면 어떤 필드 마스크를 사용해야 할까?
풀이
"hello.world"에는 마침표(.)가 포함되어 있다!
마침표는 필드 경로 구분자이므로, 그냥 쓰면 잘못 해석된다.
잘못된 예:
fieldMask=settings.hello.world
→ settings 안의 hello 안의 world (3단계 중첩으로 해석!)
올바른 예:
fieldMask=settings.`hello.world`
→ settings 맵에서 키가 "hello.world"인 항목 (2단계, 키에 마침표 포함)
규칙: 맵 키에 특수문자(마침표, 별표 등)가 있으면 백틱(`)으로 감싸야 한다.
실제 HTTP 요청:
PATCH /chatRooms/1?fieldMask=settings.`hello.world` HTTP/1.1
Content-Type: application/json
{
"settings": {
"hello.world": "new value"
}
}
전체 정리 — 한눈에 보는 8장
문제: 리소스 전체를 매번 주고받으면 낭비, 충돌, 유실이 생긴다
↓
해결: 필드 마스크 (Field Mask) = 다룰 필드의 이름 목록
↓
┌─────────────┬──────────────┐
│ 부분 취득 │ 부분 업데이트 │
│ GET + FM │ PATCH + FM │
└──────┬──────┴──────┬───────┘
│ │
┌──────┴──────┐ ┌────┴────────────────┐
│ 전달: 쿼리 │ │ 전달: 쿼리 매개변수 │
│ 매개변수 │ │ + 본문에 리소스 데이터│
└──────┬──────┘ └────┬────────────────┘
│ │
┌──────┴─────────────┴───────┐
│ 필드 경로 표현 규칙 │
│ . = 중첩 / * = 전체 │
│ ` = 맵 키 인용 │
│ 배열 = 전체 교체만 가능 │
└──────┬─────────────────────┘
│
┌──────┴──────┐
│ 기본값 │
│ GET: * (전체)│
│ PATCH: 추정 │
└─────────────┘
부록 A: 용어 사전
| 용어 | 영문 | 설명 |
|---|---|---|
| 필드 마스크 | Field Mask | 다룰 필드의 이름을 나열한 문자열 배열 |
| 부분 취득 | Partial Get | 리소스에서 원하는 필드만 가져오기 |
| 부분 업데이트 | Partial Update | 리소스에서 원하는 필드만 수정하기 |
| 센티넬 값 | Sentinel Value | "모든 것"을 의미하는 특별한 값 (여기서는 "*") |
| 묵시적 필드 마스크 | Implicit Field Mask | 요청 데이터에서 자동 추정된 필드 마스크 |
| 중첩 인터페이스 | Nested Interface | 리소스 안에 포함된 또 다른 구조체 |
| 반복 필드 | Repeated Field | 배열 형태의 필드 |
| 맵 필드 | Map Field | 키-값 쌍의 동적 컬렉션 |
| replace 표준 메서드 | Standard Replace Method | 리소스 전체를 교체하는 HTTP PUT 방식 |
| update 표준 메서드 | Standard Update Method | 리소스 일부를 수정하는 HTTP PATCH 방식 |
부록 B: 핵심 비교표
HTTP 메서드별 비교
| HTTP 메서드 | 용도 | 필드 마스크 위치 | 본문 내용 |
|---|---|---|---|
| GET | 부분 취득 | 쿼리 매개변수 | 없음 |
| PUT | 전체 교체 | 불필요 | 전체 리소스 |
| PATCH | 부분 업데이트 | 쿼리 매개변수 | 수정할 리소스 데이터 |
필드 마스크 표현 규칙 요약
| 상황 | 표현 | 예시 |
|---|---|---|
| 단순 필드 | 필드 이름 그대로 | "title" |
| 중첩 필드 | 마침표(.)로 경로 | "loggingConfig.maxSizeMb" |
| 중첩 전체 | 별표(*) | "loggingConfig.*" |
| 맵 키 (숫자) | 백틱으로 감싸기 | "settings.\1234`"` |
| 맵 키 (특수문자) | 백틱으로 감싸기 | "settings.\test.value`"` |
| 배열 전체 | 필드 이름 | "administrators" |
| 배열 항목의 필드 | 별표 + 필드 | "administrators.*.name" |
| 모든 필드 | 별표 단독 | "*" |
부록 C: 추천 참고 자료 & 링크
| 자료 | 설명 |
|---|---|
| API Design Patterns (JJ Geewax) | 이 학습 노트의 원본 교재 |
| Google AIP-134 (Standard Update) | Google API 부분 업데이트 가이드 |
| Google AIP-157 (Partial Response) | Google API 부분 응답 가이드 |
| JSON Merge Patch (RFC 7396) | JSON 부분 업데이트 표준 |
| JSON Patch (RFC 6902) | JSON 패치 연산 표준 |
| Google Protobuf FieldMask | 필드 마스크의 원조 구현체 |
클릭하거나 Space를 눌러 뒤집기